Классификация изображений художников разного стиля с использованием PyTorch¶

Первая часть задания: создание архитектуры классификации изображений с использованием PyTorch¶

Импорт библиотек, фиксация рандома torch.manual_seed(1), определение на чем будем считать: cpu или gpu.

In [1]:
import torch
torch.manual_seed(1)
import torchvision
import torchvision.transforms as transforms
import torch.nn as nn
import torch.optim as optim
#import os
import torch.nn.functional as F
import matplotlib.pyplot as plt
from collections import defaultdict # to check distribution by classes
from sklearn.metrics import precision_recall_fscore_support # to calculate F1 score
from sklearn.model_selection import StratifiedShuffleSplit # to split images to train, val, and test
import numpy as np
import pandas as pd
import seaborn as sns

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
C:\Users\elena\AppData\Local\Programs\Python\Python310\lib\site-packages\tqdm\auto.py:22: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm

Определяем трансформацию изображений: отдельно для тренировочного набора с аугментацией данных и отдельно для тестового и валидационного.

In [2]:
# define default transormation

val_transform = transforms.Compose([
    transforms.Resize(64),
    transforms.CenterCrop(64),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

train_transform = transforms.Compose([
    transforms.RandomHorizontalFlip(p = 0.3),
    transforms.RandomVerticalFlip(p = 0.3),
    transforms.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.4),
    transforms.RandomGrayscale(p=0.2),
    transforms.RandomRotation(30),
    transforms.RandomPerspective(p=0.3),
    transforms.GaussianBlur(kernel_size=5, sigma=(0.5, 3.0)),
    #transforms.GaussianNoise(0.1),

    transforms.Resize(64),
    transforms.CenterCrop(64),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
    #transforms.Normalize()
    #transforms.Normalize(mean, std)

])

Загрузка изображений, которые хранятся в директории images. Загружаем два раза один и тот же набор изображений, потому что оказалось что трансформация с аугментацией данных на тренировочном наборе распространяется и на тестовый набор (например вот ссылка). Поэтому принято решение считать два раза и сразу применить нужную трансформацию.

In [3]:
# image_dataset = torchvision.datasets.ImageFolder('images', transform=val_transform)
train_dataset_whole = torchvision.datasets.ImageFolder('images', transform=train_transform)
test_valid_dataset = torchvision.datasets.ImageFolder('images', transform=val_transform)

# print(len(train_dataset_whole))
# print(train_dataset_whole.transform)
image_classes = train_dataset_whole.classes
# print(image_classes)

Разбиваем изображения на трейновый, валидационный и тестовый набор (70%, 20%, 10%) без стратификации. Оказалось, что это работает хуже чем со стратификацией с учетом размеров классов. Поэтому код без стратификации закомментирован.

In [4]:
# train_size = int(0.7 * len(image_dataset))
# val_size = int(0.2 * len(image_dataset))
# test_size = len(image_dataset) - train_size - val_size
# train_dataset, val_dataset, test_dataset = torch.utils.data.random_split(image_dataset, [train_size, val_size, test_size])
# # realize data augmentation for train set
# train_dataset.dataset.transform = train_transform
In [5]:
# print("Train set size: {:}, validation set size: {:}, test set size: {:}".format(train_size, val_size, test_size))

# print(train_size)
# print(val_size)
# print(test_size)
# print(train_dataset.dataset)
In [6]:
# загрузка данных соответствующего размера
# train_dataloader = torch.utils.data.DataLoader(train_dataset, batch_size=32, shuffle=True)
# val_dataloader = torch.utils.data.DataLoader(val_dataset, batch_size=32, shuffle=False)
# test_dataloader = torch.utils.data.DataLoader(test_dataset, batch_size=32, shuffle=False)

Разбиваем изображения на трейновый, валидационный и тестовый набор с учетом размеров классов. Такое разбиение позволяет учитывать несбалансированность классов, которая у нас присутствует. Таким образом, от каждого класса будет использовано 70% в трейновом датасете, 20% в валидационном и 10% в тестовом. Кроме того, задаем random_state для воспроизводимости

In [4]:
# from sklearn.model_selection import StratifiedShuffleSplit

# Get the labels for each image
# labels = [image_dataset[i][1] for i in range(len(image_dataset))]
labels = [label for _, label in train_dataset_whole]

# Create the stratified shuffle split object
split = StratifiedShuffleSplit(n_splits=1, test_size=0.3, random_state=42)

# Split the indices of the images into train and validation-test sets
train_index, test_valid_index = next(split.split(range(len(train_dataset_whole)), labels))
test_valid_dataset = torch.utils.data.Subset(test_valid_dataset, test_valid_index)

# Create a second stratified shuffle split object for the train set
split = StratifiedShuffleSplit(n_splits=1, test_size=0.4, random_state=42)
val_index, test_index = next(split.split(range(len(test_valid_dataset)), [label for _, label in test_valid_dataset]))
test_dataset = torch.utils.data.Subset(test_valid_dataset, test_index)
val_dataset = torch.utils.data.Subset(test_valid_dataset, val_index)
In [5]:
# загрузка данных соответствующего размера
train_dataset = torch.utils.data.Subset(train_dataset_whole, train_index)
# train_dataset.dataset.transform = train_transform
# print(train_dataset.dataset)
batch_size = 32 # определяем размер батча
train_dataloader = torch.utils.data.DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
val_dataloader = torch.utils.data.DataLoader(val_dataset, batch_size=batch_size, shuffle=False)
test_dataloader = torch.utils.data.DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
In [6]:
print(test_valid_dataset.dataset.transform) # to verify that applied transformation is correct
print(train_dataset.dataset.transform) # to verify that applied transformation is correct
print(image_classes)
print(len(image_classes))
# print(test_valid_index)
# print(test_dataloader.dataset.indices)
print("Train set size: {:}, validation set size: {:}, test set size: {:}".format(train_index.size, val_index.size, test_index.size))
Compose(
    Resize(size=64, interpolation=bilinear, max_size=None, antialias=None)
    CenterCrop(size=(64, 64))
    ToTensor()
    Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
)
Compose(
    RandomHorizontalFlip(p=0.3)
    RandomVerticalFlip(p=0.3)
    ColorJitter(brightness=[0.6, 1.4], contrast=[0.6, 1.4], saturation=[0.6, 1.4], hue=None)
    RandomGrayscale(p=0.2)
    RandomRotation(degrees=[-30.0, 30.0], interpolation=nearest, expand=False, fill=0)
    RandomPerspective(p=0.3)
    GaussianBlur(kernel_size=(5, 5), sigma=(0.5, 3.0))
    Resize(size=64, interpolation=bilinear, max_size=None, antialias=None)
    CenterCrop(size=(64, 64))
    ToTensor()
    Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
)
['ArtDeco', 'Cubism', 'Impressionism', 'Japonism', 'Naturalism', 'Rococo', 'cartoon', 'photo']
8
Train set size: 995, validation set size: 256, test set size: 171

Сохраняем использовавшиеся индексы для обучения модели и тестирования, чтобы повторно использовать их во второй и третьей части задания.

In [7]:
np.save('data/train_index_42.npy', train_index)
# np.savetxt("features_best.csv", features_np, delimiter=",")
np.save('data/test_index_42.npy', test_valid_index)

Проверим результат разбиения по классам

In [8]:
def count_classes(dataloader):
    labels_count = defaultdict(int)
    for i, data in enumerate(dataloader, 0):
        inputs, labels = data
        for label in labels:
            labels_count[label.item()] += 1
    return(labels_count)

data = {'Train set': count_classes(train_dataloader),
        'Validation set': count_classes(val_dataloader),
        'Test set': count_classes(test_dataloader)}
# print(data)

Отрисуем результат разбиения по классам на тренировочный, валидационный и тестовый набор

In [9]:
keys = list(data['Train set'].keys())
train_values = list(data['Train set'].values())
val_values = list(data['Validation set'].values())
test_values = list(data['Test set'].values())

df = pd.DataFrame()
for group, items in data.items():
    temp = pd.DataFrame.from_dict(items, orient='index', columns=[group])
    df = pd.concat([df, temp], axis=1, sort=False)

# преобразуем данные в длинный формат для построения графика

df = df.reset_index().melt(id_vars='index', value_vars=list(data.keys()), value_name='count', var_name='type')
df.rename(columns={'index': 'classes'}, inplace=True)

sns.catplot(x="classes", y="count", kind="bar", hue = 'type', 
            data=df, dodge=True, edgecolor = 'black', palette=sns.color_palette(['#fc8d59', '#ffffbf', '#91cf60']))

plt.show()

Определяем функцию для отрисовки изображений с лейблами

In [10]:
def showimages(imgs,actual_lbls,pred_lbls=None):
  
  fig = plt.figure(figsize=(21,12))

  for i,img in enumerate(imgs):
    
    fig.add_subplot(4,8, i+1)
    y=actual_lbls[i]
    
    if pred_lbls!=None:
      y_pre=pred_lbls[i]
      title="prediction: {0}\nlabel:{1}".format(image_classes[y_pre], image_classes[y])
    else: 
      title="Label: {0}".format(image_classes[y])

    plt.title(title)
    img = img.numpy().transpose((1, 2, 0))
    mean = np.array([0.485, 0.456, 0.406])
    std = np.array([0.229, 0.224, 0.225])
    img = std * img + mean
    img = np.clip(img, 0, 1)
    plt.axis("off")
    plt.imshow(img)
  
# plt.show()
In [11]:
inputs, classes = next(iter(test_dataloader)) # отрисуем тестовые изображения

showimages(inputs, classes) # убедились что аугментация не была применена для теста, как и было задумано
In [12]:
inputs, classes = next(iter(train_dataloader)) # отрисуем трейновые изображения

showimages(inputs, classes) # аугментация удалась

Определяем модель для классификации изображений: resnet с 18 слоями, задаем на полносвязном уровне количество наших классов. Модель с дропаутом показывала хуже результат, а вот L2-регуляризация оказалась полезной.

In [13]:
model = torchvision.models.resnet18(weights='ResNet18_Weights.IMAGENET1K_V1')
# model = torchvision.models.resnet34(weights='ResNet34_Weights.IMAGENET1K_V1')

#model = torchvision.models.resnet50(weights=True)

model.fc = nn.Linear(512, len(image_classes))

# dropout

# model.fc.register_forward_hook(lambda m, inp, out: F.dropout(out, p=0.1, training=m.training))

#model.fc.register_forward_hook(lambda m, inp, out: F.dropout(out, p=0.2, training=m.training))

Выбираем функцию ошибки CrossEntropyLoss, поскольку у нас задача классификации нескольких классов. Например, можно почитать об этом здесь, здесь. Использование стохастического градиентного спуска показало самый лучший результат в этой задаче. Еще полезным оказался параметр weight_decay для L2-регуляризации.

In [14]:
criterion = nn.CrossEntropyLoss()
# optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
# optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9,  weight_decay=0.001)
# optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9,  weight_decay=0.002)
optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9,  weight_decay=0.01)
# optimizer = optim.Adam(model.parameters())
# optimizer = optim.RMSprop(model.parameters()) # this optimizer functioned really bad
In [15]:
train_losses = []
val_losses = []
train_accuracies = []
val_accuracies = []
# f1_scores = []

Запускаем обучение нейросети на 30 эпохах (большее число эпох не улучшало показатели). Модель, для которой показан самый лучший (минимальный) лосс на валидационном скоре сохраняем в файл для последущей загрузки.

In [16]:
num_epochs = 30

best_accuracy = 0
best_loss = float('inf')
best_epoch = 0
# best_f1_score = 0
for epoch in range(num_epochs):
    running_loss = 0.0
    correct = 0
    total = 0
    for i, data in enumerate(train_dataloader, 0):
        inputs, labels = data
        # Zero the parameter gradients
        optimizer.zero_grad()

        # Forward pass
        outputs = model(inputs)
        loss = criterion(outputs, labels)

        # Backward pass and optimization
        loss.backward()
        optimizer.step()

        # Record the loss and accuracy
        running_loss += loss.item()
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()
    
    # Calculate the average loss and accuracy for the epoch
    train_loss = running_loss / len(train_dataloader)
    train_accuracy = 100 * correct / total
    train_losses.append(train_loss)
    train_accuracies.append(train_accuracy)

    # Валидация модели
    with torch.no_grad():
        running_loss_val = 0.0
        total = 0
        correct = 0
#         confusion_matrix = torch.zeros(len(image_classes), len(image_classes), dtype=torch.int64)
        true_labels = []
        pred_labels = []

        for i, data in enumerate(val_dataloader, 0):
            inputs, labels = data
            inputs, labels = inputs.to(device), labels.to(device)
            outputs = model(inputs)
            loss_val = criterion(outputs, labels)
            running_loss_val += loss_val.item()
            _, preds = torch.max(outputs, dim=1)
            total += labels.size(0)
            correct += (preds == labels).sum().item()

            true_labels.extend(labels.tolist())
            pred_labels.extend(preds.tolist())

        val_loss = running_loss_val / len(val_dataloader)
        val_accuracy = 100 * correct / total


    val_losses.append(val_loss)
    val_accuracies.append(val_accuracy)
#     f1_scores.append(f1_score)

    print("Epoch {}/{} - Train Loss: {:.4f} - Train Accuracy: {:.2f}% - Val Loss: {:.4f} - Val Accuracy: {:.4f}%".format(
        epoch + 1, num_epochs, train_loss, train_accuracy, val_loss, val_accuracy))
    if val_loss < best_loss:
        best_loss = val_loss
        best_epoch = epoch
        torch.save(model.state_dict(), 'data/best_model_finally_42_1seed.pt')
Epoch 1/30 - Train Loss: 2.0390 - Train Accuracy: 25.03% - Val Loss: 1.7221 - Val Accuracy: 37.5000%
Epoch 2/30 - Train Loss: 1.6080 - Train Accuracy: 42.51% - Val Loss: 1.5088 - Val Accuracy: 46.0938%
Epoch 3/30 - Train Loss: 1.5180 - Train Accuracy: 46.53% - Val Loss: 1.4401 - Val Accuracy: 46.8750%
Epoch 4/30 - Train Loss: 1.4490 - Train Accuracy: 50.25% - Val Loss: 1.4035 - Val Accuracy: 49.6094%
Epoch 5/30 - Train Loss: 1.2998 - Train Accuracy: 54.77% - Val Loss: 1.3464 - Val Accuracy: 54.2969%
Epoch 6/30 - Train Loss: 1.2940 - Train Accuracy: 54.27% - Val Loss: 1.2974 - Val Accuracy: 53.1250%
Epoch 7/30 - Train Loss: 1.2693 - Train Accuracy: 58.29% - Val Loss: 1.2168 - Val Accuracy: 56.6406%
Epoch 8/30 - Train Loss: 1.1585 - Train Accuracy: 61.11% - Val Loss: 1.2578 - Val Accuracy: 57.0312%
Epoch 9/30 - Train Loss: 1.2193 - Train Accuracy: 58.39% - Val Loss: 1.2091 - Val Accuracy: 58.2031%
Epoch 10/30 - Train Loss: 1.1055 - Train Accuracy: 63.62% - Val Loss: 1.1988 - Val Accuracy: 56.6406%
Epoch 11/30 - Train Loss: 1.0960 - Train Accuracy: 62.11% - Val Loss: 1.1968 - Val Accuracy: 57.8125%
Epoch 12/30 - Train Loss: 1.0595 - Train Accuracy: 64.12% - Val Loss: 1.1986 - Val Accuracy: 58.5938%
Epoch 13/30 - Train Loss: 1.0498 - Train Accuracy: 64.42% - Val Loss: 1.2364 - Val Accuracy: 57.8125%
Epoch 14/30 - Train Loss: 1.0531 - Train Accuracy: 64.52% - Val Loss: 1.1787 - Val Accuracy: 60.5469%
Epoch 15/30 - Train Loss: 0.9864 - Train Accuracy: 66.93% - Val Loss: 1.1502 - Val Accuracy: 62.1094%
Epoch 16/30 - Train Loss: 1.0753 - Train Accuracy: 64.62% - Val Loss: 1.1412 - Val Accuracy: 60.9375%
Epoch 17/30 - Train Loss: 0.9959 - Train Accuracy: 67.84% - Val Loss: 1.1463 - Val Accuracy: 62.1094%
Epoch 18/30 - Train Loss: 1.0192 - Train Accuracy: 65.33% - Val Loss: 1.1610 - Val Accuracy: 60.5469%
Epoch 19/30 - Train Loss: 0.9491 - Train Accuracy: 66.73% - Val Loss: 1.1623 - Val Accuracy: 63.2812%
Epoch 20/30 - Train Loss: 0.8969 - Train Accuracy: 68.64% - Val Loss: 1.1484 - Val Accuracy: 60.1562%
Epoch 21/30 - Train Loss: 0.8518 - Train Accuracy: 71.06% - Val Loss: 1.1507 - Val Accuracy: 57.4219%
Epoch 22/30 - Train Loss: 0.7449 - Train Accuracy: 75.18% - Val Loss: 1.1563 - Val Accuracy: 61.7188%
Epoch 23/30 - Train Loss: 0.7786 - Train Accuracy: 72.56% - Val Loss: 1.1990 - Val Accuracy: 60.9375%
Epoch 24/30 - Train Loss: 0.8212 - Train Accuracy: 71.46% - Val Loss: 1.1579 - Val Accuracy: 63.2812%
Epoch 25/30 - Train Loss: 0.7554 - Train Accuracy: 74.97% - Val Loss: 1.2208 - Val Accuracy: 60.5469%
Epoch 26/30 - Train Loss: 0.8044 - Train Accuracy: 74.07% - Val Loss: 1.1128 - Val Accuracy: 62.8906%
Epoch 27/30 - Train Loss: 0.9059 - Train Accuracy: 69.15% - Val Loss: 1.2439 - Val Accuracy: 60.9375%
Epoch 28/30 - Train Loss: 0.8852 - Train Accuracy: 71.56% - Val Loss: 1.1767 - Val Accuracy: 61.3281%
Epoch 29/30 - Train Loss: 0.7730 - Train Accuracy: 74.87% - Val Loss: 1.1934 - Val Accuracy: 60.5469%
Epoch 30/30 - Train Loss: 0.7300 - Train Accuracy: 74.67% - Val Loss: 1.1408 - Val Accuracy: 61.3281%

На всякий случай сохраняю последнее состояние модели тоже, если понадобится сравнить. Иногда оказывается, что модель, обученная на полном числе эпох (30) показывает лучшие показатели качества.

In [17]:
torch.save(model.state_dict(), 'data/best_model_42_1seed_last.pt')

Строим графики изменения функции ошибки и точности на трейновом и валидационном наборе данных

In [18]:
epochs = range(1, num_epochs + 1)
plt.subplot(2, 1, 1)
plt.plot(epochs, train_losses, label='Train Loss')
plt.plot(epochs, val_losses, label='Validation Loss')
plt.title('Train and Validation Loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.xticks(range(1, num_epochs+1))
plt.legend()
plt.axvline(x = best_epoch+1, linestyle = '--')
plt.xticks(rotation=45)

plt.show()

plt.subplot(2, 1, 2)
plt.plot(epochs, train_accuracies, label='Train Accuracy')
plt.plot(epochs, val_accuracies, label='Validation Accuracy')
plt.title('Train and Validation Accuracy')
plt.xticks(range(1, num_epochs+1))
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()
plt.xticks(rotation=45)

plt.axvline(x = best_epoch+1, linestyle = '--')
plt.show()

print(best_loss)
print(best_epoch)

#plt.plt(train_losses, val_losses)
1.1127616465091705
25

Можно увидеть, что точность на тренировочном наборе увеличивается, а лосс уменьшается, но впоследствии точность на валидацонном и лосс не улучшаются, поэтому используем сохраненный лучший стейт модели. Можно сделать вывод, что модель сходится.

In [27]:
model.load_state_dict(torch.load('data/best_model_finally_42_1seed.pt'))
Out[27]:
<All keys matched successfully>

Тестируем качество нашей модели на тестовой выборке. Вычисляем точность и F1-score (выбран взвешенный, чтобы каждый класс изображений имел равный вклад).

In [28]:
# Initialize the confusion matrix

confusion_matrix = torch.zeros(len(image_classes), len(image_classes), dtype=torch.int64)

true_labels = []
pred_labels = []

# Evaluation mode
model.eval()

# Set the gradient computation to be off
with torch.no_grad():
    running_loss = 0.0
    total = 0
    correct = 0
    for inputs, labels in test_dataloader:
        inputs, labels = inputs.to(device), labels.to(device)
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        running_loss += loss.item()
        _, preds = torch.max(outputs, dim=1)
        total += labels.size(0)
        correct += (preds == labels).sum().item()
        true_labels.extend(labels.tolist())
        pred_labels.extend(preds.tolist())
        # 
        for t, p in zip(labels, preds):
            confusion_matrix[t, p] += 1
    test_loss = running_loss / len(test_dataloader)
    test_accuracy = 100 * correct / total

# Вычислим чувствительность, специфичность и f1-score
precision, recall, f1_score, _ = precision_recall_fscore_support(true_labels, pred_labels, average='weighted')

print("Test loss: {:.4f}, test Accuracy: {:.2f}%, weighted test F1-Score: {:.4f}".format(test_loss, test_accuracy, f1_score))
print('Precision: {:.4f}, recall: {:.4f}'.format(precision, recall))
Test loss: 1.1987, test Accuracy: 63.16%, weighted test F1-Score: 0.6254
Precision: 0.6539, recall: 0.6316

63% Точности и 62.5% f1 score это не супер хорошие результаты, однако на этих данных с тюнингом гиперпараметров не удавалось получать результаты значительно лучше. Один раз удалось достигнуть точности 85%, однако это делалось без фиксации сидов, в результате не получилось воспроизвести :(

In [29]:
# print(confusion_matrix)
# для красивого отображения confusion matrix:
dataframe = pd.DataFrame(confusion_matrix, index=image_classes, columns=image_classes)
# print(dataframe)
plt.figure(figsize=(8, 6))

# Create heatmap
sns.heatmap(dataframe, annot=True, cbar=None,cmap="YlGnBu",fmt="d")

plt.title("Confusion Matrix"), plt.tight_layout()

plt.ylabel("True Class"), 
plt.xlabel("Predicted Class")
plt.show()

Посмотрим на часть тестовых изображений с предсказанными и истинными лейблами

In [30]:
def predict_images(model,images,actual_label):
  model.eval()
  with torch.no_grad():
    inputs = images.to(device)
    outputs = model(inputs)
    _, preds = torch.max(outputs, 1)
    showimages(images, actual_label, preds.cpu())
    

images, classes = next(iter(test_dataloader))

predict_images(model,images,classes)

Если нужно проверить другой стейт модели:

In [ ]:
# model.load_state_dict(torch.load('data/best_model_42_1seed_last.pt'))